Rolling Horizon¶
Solve large operational problems by decomposing the time horizon into sequential segments.
This notebook introduces:
- Rolling horizon optimization: Divide time into overlapping segments
- State transfer: Pass storage states and flow history between segments
- When to use: Memory limits, operational planning with limited foresight
We use a realistic district heating system with CHP, boiler, and storage to demonstrate the approach.
Setup¶
import timeit
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import xarray as xr
from plotly.subplots import make_subplots
import flixopt as fx
fx.CONFIG.notebook()
flixopt.config.CONFIG
Load Time Series Data¶
We use real-world district heating data at 15-minute resolution (two weeks):
# Load time series data (15-min resolution)
data = pd.read_csv('data/Zeitreihen2020.csv', index_col=0, parse_dates=True).sort_index()
data = data['2020-01-01':'2020-01-14 23:45:00'] # Two weeks
data.index.name = 'time' # Rename index for consistency
timesteps = data.index
# Extract profiles
electricity_demand = data['P_Netz/MW'].to_numpy()
heat_demand = data['Q_Netz/MW'].to_numpy()
electricity_price = data['Strompr.€/MWh'].to_numpy()
gas_price = data['Gaspr.€/MWh'].to_numpy()
print(f'Timesteps: {len(timesteps)} ({len(timesteps) / 96:.0f} days at 15-min resolution)')
print(f'Heat demand: {heat_demand.min():.1f} - {heat_demand.max():.1f} MW')
print(f'Electricity price: {electricity_price.min():.1f} - {electricity_price.max():.1f} €/MWh')
Timesteps: 1344 (14 days at 15-min resolution) Heat demand: 122.2 - 254.3 MW Electricity price: -3.3 - 72.6 €/MWh
def build_system(timesteps, heat_demand, electricity_demand, electricity_price, gas_price):
"""Build a district heating system with CHP, boiler, and storage."""
fs = fx.FlowSystem(timesteps)
# Effects
# Buses
fs.add_elements(
fx.Bus('Electricity'),
fx.Bus('Heat'),
fx.Bus('Gas'),
fx.Bus('Coal'),
fx.Effect('costs', '€', 'Total Costs', is_standard=True, is_objective=True),
fx.Effect('CO2', 'kg', 'CO2 Emissions'),
fx.linear_converters.CHP(
'CHP',
thermal_efficiency=0.58,
electrical_efficiency=0.22,
status_parameters=fx.StatusParameters(effects_per_startup=24000),
electrical_flow=fx.Flow('P_el', bus='Electricity', size=200),
thermal_flow=fx.Flow('Q_th', bus='Heat', size=200),
fuel_flow=fx.Flow('Q_fu', bus='Coal', size=288, relative_minimum=87 / 288, previous_flow_rate=100),
),
fx.linear_converters.Boiler(
'Boiler',
thermal_efficiency=0.85,
thermal_flow=fx.Flow('Q_th', bus='Heat'),
fuel_flow=fx.Flow(
'Q_fu',
bus='Gas',
size=95,
relative_minimum=12 / 95,
previous_flow_rate=20,
status_parameters=fx.StatusParameters(effects_per_startup=1000),
),
),
fx.Storage(
'Storage',
capacity_in_flow_hours=684,
initial_charge_state=137,
minimal_final_charge_state=137,
maximal_final_charge_state=158,
eta_charge=1,
eta_discharge=1,
relative_loss_per_hour=0.001,
prevent_simultaneous_charge_and_discharge=True,
charging=fx.Flow('Charge', size=137, bus='Heat'),
discharging=fx.Flow('Discharge', size=158, bus='Heat'),
),
fx.Source(
'GasGrid',
outputs=[fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={'costs': gas_price, 'CO2': 0.3})],
),
fx.Source(
'CoalSupply',
outputs=[fx.Flow('Q_Coal', bus='Coal', size=1000, effects_per_flow_hour={'costs': 4.6, 'CO2': 0.3})],
),
fx.Source(
'GridBuy',
outputs=[
fx.Flow(
'P_el',
bus='Electricity',
size=1000,
effects_per_flow_hour={'costs': electricity_price + 0.5, 'CO2': 0.3},
)
],
),
fx.Sink(
'GridSell',
inputs=[fx.Flow('P_el', bus='Electricity', size=1000, effects_per_flow_hour=-(electricity_price - 0.5))],
),
fx.Sink('HeatDemand', inputs=[fx.Flow('Q_th', bus='Heat', size=1, fixed_relative_profile=heat_demand)]),
fx.Sink(
'ElecDemand', inputs=[fx.Flow('P_el', bus='Electricity', size=1, fixed_relative_profile=electricity_demand)]
),
)
return fs
flow_system = build_system(timesteps, heat_demand, electricity_demand, electricity_price, gas_price)
print(f'System: {len(timesteps)} timesteps')
System: 1344 timesteps
Full Optimization (Baseline)¶
First, solve the full problem as a baseline:
solver = fx.solvers.HighsSolver()
start = timeit.default_timer()
fs_full = flow_system.copy()
fs_full.optimize(solver)
time_full = timeit.default_timer() - start
print(f'Full optimization: {time_full:.2f} seconds')
print(f'Cost: {fs_full.solution["costs"].item():,.0f} €')
2025-12-17 12:56:31.087 WARNING │ FlowSystem is not connected_and_transformed. Connecting and transforming data now.
Writing constraints.: 0%| | 0/74 [00:00<?, ?it/s]
Writing constraints.: 24%|██▍ | 18/74 [00:00<00:00, 173.90it/s]
Writing constraints.: 49%|████▊ | 36/74 [00:00<00:00, 165.93it/s]
Writing constraints.: 72%|███████▏ | 53/74 [00:00<00:00, 163.69it/s]
Writing constraints.: 96%|█████████▌| 71/74 [00:00<00:00, 166.42it/s]
Writing constraints.: 100%|██████████| 74/74 [00:00<00:00, 164.92it/s]
Writing continuous variables.: 0%| | 0/56 [00:00<?, ?it/s]
Writing continuous variables.: 100%|██████████| 56/56 [00:00<00:00, 657.76it/s]
Writing binary variables.: 0%| | 0/11 [00:00<?, ?it/s]
Writing binary variables.: 100%|██████████| 11/11 [00:00<00:00, 673.25it/s] Running HiGHS 1.12.0 (git hash: 755a8e0): Copyright (c) 2025 HiGHS under MIT licence terms MIP linopy-problem-m6slxrr9 has 53792 rows; 51102 cols; 168036 nonzeros; 14784 integer variables (14784 binary) Coefficient ranges: Matrix [1e-05, 2e+04] Cost [1e+00, 1e+00] Bound [1e+00, 1e+03] RHS [1e-05, 2e+02] WARNING: Problem has some excessively small row bounds Presolving model 29568 rows, 24168 cols, 76581 nonzeros 0s 25322 rows, 18981 cols, 72124 nonzeros 0s
24294 rows, 18173 cols, 69378 nonzeros 0s Presolve reductions: rows 24294(-29498); columns 18173(-32929); nonzeros 69378(-98658) Solving MIP model with: 24294 rows 18173 cols (13978 binary, 0 integer, 0 implied int., 4195 continuous, 0 domain fixed) 69378 nonzeros
Src: B => Branching; C => Central rounding; F => Feasibility pump; H => Heuristic;
I => Shifting; J => Feasibility jump; L => Sub-MIP; P => Empty MIP; R => Randomized rounding;
S => Solve LP; T => Evaluate node; U => Unbounded; X => User solution; Y => HiGHS solution;
Z => ZI Round; l => Trivial lower; p => Trivial point; u => Trivial upper; z => Trivial zero
Nodes | B&B Tree | Objective Bounds | Dynamic Constraints | Work
Src Proc. InQueue | Leaves Expl. | BestBound BestSol Gap | Cuts InLp Confl. | LpIters Time
0 0 0 0.00% 1030491.76973 inf inf 0 0 0 0 0.5s
0 0 0 0.00% 1540084.733469 inf inf 0 0 0 9609 0.8s
C 0 0 0 0.00% 1540120.500951 1662404.794591 7.36% 10469 2304 0 13269 3.1s
0 0 0 0.00% 1540128.708868 1662404.794591 7.36% 9488 2262 0 14325 8.2s
L 0 0 0 0.00% 1540128.778492 1546847.072607 0.43% 9566 2281 0 14381 13.3s
1 0 1 100.00% 1540128.778492 1546847.072607 0.43% 9566 2281 0 18039 13.3s
Solving report
Model linopy-problem-m6slxrr9
Status Optimal
Primal bound 1546847.07261
Dual bound 1540128.77849
Gap 0.434% (tolerance: 1%)
P-D integral 0.749910084366
Solution status feasible
1546847.07261 (objective)
0 (bound viol.)
0 (int. viol.)
0 (row viol.)
Timing 13.35
Max sub-MIP depth 2
Nodes 1
Repair LPs 0
LP iterations 18039
0 (strong br.)
4772 (separation)
3658 (heuristics)
Full optimization: 16.48 seconds Cost: 1,546,847 €
Rolling Horizon Optimization¶
The optimize.rolling_horizon() method divides the time horizon into segments that are solved sequentially:
Full horizon: |---------- 1344 timesteps (14 days) ----------|
Segment 1: |==== 192 (2 days) ====|-- overlap --|
Segment 2: |==== 192 (2 days) ====|-- overlap --|
Segment 3: |==== 192 (2 days) ====|-- overlap --|
...
Key parameters:
- horizon: Timesteps per segment (excluding overlap)
- overlap: Additional lookahead timesteps (improves storage optimization)
- nr_of_previous_values: Flow history transferred between segments
start = timeit.default_timer()
fs_rolling = flow_system.copy()
segments = fs_rolling.optimize.rolling_horizon(
solver,
horizon=192, # 2-day segments (192 timesteps at 15-min resolution)
overlap=96, # 1-day lookahead
)
time_rolling = timeit.default_timer() - start
print(f'Rolling horizon: {time_rolling:.2f} seconds ({len(segments)} segments)')
print(f'Cost: {fs_rolling.solution["costs"].item():,.0f} €')
Solving segments: 0%| | 0/7 [00:00<?, ?segment/s]
Segment 1/7 (timesteps 0-288): 0%| | 0/7 [00:00<?, ?segment/s]
Writing constraints.: 0%| | 0/74 [00:00<?, ?it/s]
Writing constraints.: 30%|██▉ | 22/74 [00:00<00:00, 210.73it/s]
Writing constraints.: 59%|█████▉ | 44/74 [00:00<00:00, 202.57it/s]
Writing constraints.: 88%|████████▊ | 65/74 [00:00<00:00, 201.80it/s]
Writing constraints.: 100%|██████████| 74/74 [00:00<00:00, 200.95it/s]
Writing continuous variables.: 0%| | 0/56 [00:00<?, ?it/s]
Writing continuous variables.: 100%|██████████| 56/56 [00:00<00:00, 714.67it/s]
Writing binary variables.: 0%| | 0/11 [00:00<?, ?it/s]
Writing binary variables.: 100%|██████████| 11/11 [00:00<00:00, 730.30it/s] Segment 1/7 (timesteps 0-288): 14%|█▍ | 1/7 [00:03<00:23, 3.91s/segment]
Segment 2/7 (timesteps 192-480): 14%|█▍ | 1/7 [00:03<00:23, 3.91s/segment]
Writing constraints.: 0%| | 0/74 [00:00<?, ?it/s]
Writing constraints.: 30%|██▉ | 22/74 [00:00<00:00, 211.45it/s]
Writing constraints.: 59%|█████▉ | 44/74 [00:00<00:00, 203.71it/s]
Writing constraints.: 88%|████████▊ | 65/74 [00:00<00:00, 202.44it/s]
Writing constraints.: 100%|██████████| 74/74 [00:00<00:00, 201.39it/s]
Writing continuous variables.: 0%| | 0/56 [00:00<?, ?it/s]
Writing continuous variables.: 100%|██████████| 56/56 [00:00<00:00, 706.67it/s]
Writing binary variables.: 0%| | 0/11 [00:00<?, ?it/s]
Writing binary variables.: 100%|██████████| 11/11 [00:00<00:00, 700.82it/s] Segment 2/7 (timesteps 192-480): 29%|██▊ | 2/7 [00:09<00:25, 5.17s/segment]
Segment 3/7 (timesteps 384-672): 29%|██▊ | 2/7 [00:09<00:25, 5.17s/segment]
Writing constraints.: 0%| | 0/74 [00:00<?, ?it/s]
Writing constraints.: 30%|██▉ | 22/74 [00:00<00:00, 212.39it/s]
Writing constraints.: 59%|█████▉ | 44/74 [00:00<00:00, 204.45it/s]
Writing constraints.: 88%|████████▊ | 65/74 [00:00<00:00, 202.39it/s]
Writing constraints.: 100%|██████████| 74/74 [00:00<00:00, 202.30it/s]
Writing continuous variables.: 0%| | 0/56 [00:00<?, ?it/s]
Writing continuous variables.: 100%|██████████| 56/56 [00:00<00:00, 721.34it/s]
Writing binary variables.: 0%| | 0/11 [00:00<?, ?it/s]
Writing binary variables.: 100%|██████████| 11/11 [00:00<00:00, 678.30it/s] Segment 3/7 (timesteps 384-672): 43%|████▎ | 3/7 [00:14<00:18, 4.66s/segment]
Segment 4/7 (timesteps 576-864): 43%|████▎ | 3/7 [00:14<00:18, 4.66s/segment]
Writing constraints.: 0%| | 0/74 [00:00<?, ?it/s]
Writing constraints.: 30%|██▉ | 22/74 [00:00<00:00, 213.57it/s]
Writing constraints.: 59%|█████▉ | 44/74 [00:00<00:00, 203.92it/s]
Writing constraints.: 88%|████████▊ | 65/74 [00:00<00:00, 203.30it/s]
Writing constraints.: 100%|██████████| 74/74 [00:00<00:00, 202.64it/s]
Writing continuous variables.: 0%| | 0/56 [00:00<?, ?it/s]
Writing continuous variables.: 100%|██████████| 56/56 [00:00<00:00, 701.23it/s]
Writing binary variables.: 0%| | 0/11 [00:00<?, ?it/s]
Writing binary variables.: 100%|██████████| 11/11 [00:00<00:00, 682.03it/s] Segment 4/7 (timesteps 576-864): 57%|█████▋ | 4/7 [00:17<00:12, 4.12s/segment]
Segment 5/7 (timesteps 768-1056): 57%|█████▋ | 4/7 [00:17<00:12, 4.12s/segment]
Writing constraints.: 0%| | 0/74 [00:00<?, ?it/s]
Writing constraints.: 30%|██▉ | 22/74 [00:00<00:00, 214.09it/s]
Writing constraints.: 59%|█████▉ | 44/74 [00:00<00:00, 205.00it/s]
Writing constraints.: 88%|████████▊ | 65/74 [00:00<00:00, 203.47it/s]
Writing constraints.: 100%|██████████| 74/74 [00:00<00:00, 203.00it/s]
Writing continuous variables.: 0%| | 0/56 [00:00<?, ?it/s]
Writing continuous variables.: 100%|██████████| 56/56 [00:00<00:00, 708.76it/s]
Writing binary variables.: 0%| | 0/11 [00:00<?, ?it/s]
Writing binary variables.: 100%|██████████| 11/11 [00:00<00:00, 711.59it/s] Segment 5/7 (timesteps 768-1056): 71%|███████▏ | 5/7 [00:20<00:07, 3.77s/segment]
Segment 6/7 (timesteps 960-1248): 71%|███████▏ | 5/7 [00:20<00:07, 3.77s/segment]
Writing constraints.: 0%| | 0/74 [00:00<?, ?it/s]
Writing constraints.: 30%|██▉ | 22/74 [00:00<00:00, 212.52it/s]
Writing constraints.: 59%|█████▉ | 44/74 [00:00<00:00, 203.24it/s]
Writing constraints.: 88%|████████▊ | 65/74 [00:00<00:00, 202.48it/s]
Writing constraints.: 100%|██████████| 74/74 [00:00<00:00, 202.19it/s]
Writing continuous variables.: 0%| | 0/56 [00:00<?, ?it/s]
Writing continuous variables.: 100%|██████████| 56/56 [00:00<00:00, 719.50it/s]
Writing binary variables.: 0%| | 0/11 [00:00<?, ?it/s]
Writing binary variables.: 100%|██████████| 11/11 [00:00<00:00, 682.74it/s] Segment 6/7 (timesteps 960-1248): 86%|████████▌ | 6/7 [00:25<00:04, 4.30s/segment]
Segment 7/7 (timesteps 1152-1344): 86%|████████▌ | 6/7 [00:25<00:04, 4.30s/segment]
Segment 7/7 (timesteps 1152-1344): 100%|██████████| 7/7 [00:28<00:00, 3.83s/segment]
Segment 7/7 (timesteps 1152-1344): 100%|██████████| 7/7 [00:28<00:00, 4.09s/segment]
Rolling horizon: 28.82 seconds (7 segments) Cost: 1,540,610 €
Compare Results¶
cost_full = fs_full.solution['costs'].item()
cost_rolling = fs_rolling.solution['costs'].item()
cost_gap = (cost_rolling - cost_full) / cost_full * 100
results = pd.DataFrame(
{
'Method': ['Full optimization', 'Rolling horizon'],
'Time [s]': [time_full, time_rolling],
'Cost [€]': [cost_full, cost_rolling],
'Cost Gap [%]': [0.0, cost_gap],
}
).set_index('Method')
results.style.format({'Time [s]': '{:.2f}', 'Cost [€]': '{:,.0f}', 'Cost Gap [%]': '{:.2f}'})
| Time [s] | Cost [€] | Cost Gap [%] | |
|---|---|---|---|
| Method | |||
| Full optimization | 16.48 | 1,546,847 | 0.00 |
| Rolling horizon | 28.82 | 1,540,610 | -0.40 |
Visualize: Heat Balance Comparison¶
fs_full.statistics.plot.balance('Heat').figure.update_layout(title='Heat Balance (Full)')
fs_rolling.statistics.plot.balance('Heat').figure.update_layout(title='Heat Balance (Rolling)')
Storage State Continuity¶
Rolling horizon transfers storage charge states between segments to ensure continuity:
fig = make_subplots(
rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.1, subplot_titles=['Full Optimization', 'Rolling Horizon']
)
# Full optimization
charge_full = fs_full.solution['Storage|charge_state'].values[:-1] # Drop final value
fig.add_trace(go.Scatter(x=timesteps, y=charge_full, name='Full', line=dict(color='blue')), row=1, col=1)
# Rolling horizon
charge_rolling = fs_rolling.solution['Storage|charge_state'].values[:-1]
fig.add_trace(go.Scatter(x=timesteps, y=charge_rolling, name='Rolling', line=dict(color='orange')), row=2, col=1)
fig.update_yaxes(title_text='Charge State [MWh]', row=1, col=1)
fig.update_yaxes(title_text='Charge State [MWh]', row=2, col=1)
fig.update_layout(height=400, showlegend=False)
fig.show()
Inspect Individual Segments¶
The method returns the individual segment FlowSystems, which can be inspected:
print(f'Number of segments: {len(segments)}')
print()
for i, seg in enumerate(segments):
start_time = seg.timesteps[0]
end_time = seg.timesteps[-1]
cost = seg.solution['costs'].item()
print(
f'Segment {i + 1}: {start_time.strftime("%Y-%m-%d %H:%M")} → {end_time.strftime("%Y-%m-%d %H:%M")} | Cost: {cost:,.0f} €'
)
Number of segments: 7 Segment 1: 2020-01-01 00:00 → 2020-01-03 23:45 | Cost: 318,658 € Segment 2: 2020-01-03 00:00 → 2020-01-05 23:45 | Cost: 275,383 € Segment 3: 2020-01-05 00:00 → 2020-01-07 23:45 | Cost: 334,799 € Segment 4: 2020-01-07 00:00 → 2020-01-09 23:45 | Cost: 406,776 € Segment 5: 2020-01-09 00:00 → 2020-01-11 23:45 | Cost: 356,748 € Segment 6: 2020-01-11 00:00 → 2020-01-13 23:45 | Cost: 275,375 € Segment 7: 2020-01-13 00:00 → 2020-01-14 23:45 | Cost: 269,902 €
Visualize Segment Overlaps¶
Understanding how segments overlap is key to tuning rolling horizon. Let's visualize the flow rates from each segment including their overlap regions:
# Concatenate all segment solutions into one dataset (including overlaps)
ds = xr.concat([seg.solution for seg in segments], dim=pd.RangeIndex(len(segments), name='segment'), join='outer')
# Plot CHP thermal flow across all segments - each segment as a separate line
px.line(
ds['Boiler(Q_th)|flow_rate'].to_pandas().T,
labels={'value': 'Boiler Thermal Output [MW]', 'index': 'Timestep'},
)
px.line(
ds['Storage|charge_state'].to_pandas().T,
labels={'value': 'Storage Charge State [MW]', 'index': 'Timestep'},
)
When to Use Rolling Horizon¶
| Use Case | Recommendation |
|---|---|
| Memory limits | Large problems that exceed available memory |
| Operational planning | When limited foresight is realistic |
| Quick approximate solutions | Faster than full optimization |
| Investment decisions | Use full optimization instead |
Limitations¶
- No investments:
InvestParametersare not supported (raises error) - Suboptimal storage: Limited foresight may miss long-term storage opportunities
- Global constraints:
flow_hours_maxetc. cannot be enforced globally
API Reference¶
segments = flow_system.optimize.rolling_horizon(
solver, # Solver instance
horizon=192, # Timesteps per segment (e.g., 2 days at 15-min resolution)
overlap=48, # Additional lookahead timesteps (e.g., 12 hours)
nr_of_previous_values=1, # Flow history for uptime/downtime tracking
)
# Combined solution on original FlowSystem
flow_system.solution['costs'].item()
# Individual segment solutions
for seg in segments:
print(seg.solution['costs'].item())
Summary¶
You learned how to:
- Use
optimize.rolling_horizon()to decompose large problems - Choose horizon and overlap parameters
- Understand the trade-offs vs. full optimization
Key Takeaways¶
- Rolling horizon is useful for memory-limited or operational planning problems
- Overlap improves solution quality at the cost of computation time
- Storage states are automatically transferred between segments
- Use full optimization for investment decisions
Related Notebooks¶
- 08a-Aggregation: For investment problems, use time series aggregation (resampling, clustering) instead